'''This module provides the world object and related game and player classes.'''
from __future__ import print_function
from __future__ import unicode_literals
from __future__ import division

import stackless
import logging
import copy_reg
import io
import zipfile
import cPickle as cpickle
import cStringIO as cstringio
import weakref

from kharlia.core import NativeClass, FindGlobal, CObject, CWeakRefDescr
from kharlia.engine.level import CLevel
from kharlia.engine.actor import ENetRole, CActor, AComponent, APlayerController, AGameInfo
from kharlia.engine.client import FResource

from kharlia._engine import CPlayerBase, CLocalPlayerBase, CGameBase, CWorldBase

__all__ = [
    'MakePersistentID',
    'MakePersistentLoad',
    'CPlayer',
    'CLocalPlayer',
    'CGame',
    'CWorld'
    ]

# If True, saves type list to pickledtypes.txt
DEBUG_SAVE_PICKLED_TYPES = True

GLogger = logging.getLogger('engine')

@NativeClass
class CPlayer(CPlayerBase, CObject):
    def __init__(self, isLocal=False):
        self.__initnative__(None, isLocal)
        CObject.__init__(self)

@NativeClass
class CLocalPlayer(CLocalPlayerBase, CPlayer):
    def __init__(self):
        self.__initnative__()
        CPlayer.__init__(self, True)

def MakePersistentID(externals, base=None):
    '''
    Make a persistent ID function for pickling.
    
    Arguments:
    externals -- A dictionary mapping objects to a persistent ID.
    base -- A persistent ID function to fall back on if the object being pickled is
            not in the externals dictionary.
    '''
    if base:
        def _PersistentID(obj):
            xid = externals.get(obj, None)
            if xid:
                return '#' + xid
            else:
                return base(obj)
    else:
        def _PersistentID(obj):
            xid = externals.get(obj, None)
            if xid:
                return '#' + xid
            else:
                return None
    return _PersistentID

def MakePersistentLoad(externals, base=None):
    '''
    See MakePersistentID().
    '''
    if base:
        def _PersistentLoad(id):
            if id.startswith('#'):
                obj = externals.get(id[1:], None)
                if not obj:
                    raise RuntimeError('external reference not found: ' + id)
                return obj
            else:
                return base(id)
    else:
        def _PersistentLoad(id):
            if id.startswith('#'):
                obj = externals.get(id[1:], None)
                if not obj:
                    raise RuntimeError('external reference not found: ' + id)
                return obj
            else:
                raise RuntimeError('unknown persistent ID:' + id)
    return _PersistentLoad

@NativeClass
class CGame(CGameBase, CObject):
    '''Base class for game logic.
    
    Attributes:
    PlayerControllerClass -- PC component class constructed in Login()
    InfoClass -- Game info component class used for replication
    Info -- The game info instance if it exists
    NumPlayers -- Number of PCs in the world
    '''
    Info = CWeakRefDescr('_Info')

    def __init__(self, world):
        self.__initnative__(None, world)
        CObject.__init__(self)
        self.PlayerControllerClass = APlayerController
        self.InfoClass = AGameInfo
        self._Info = None
        self.StartPosition = None
        self.NumPlayers = 0
        self.IsSaveGame = False
        
    def __getstate__(self):
        VERSION = 3
        # 2 - Game info
        # 3 - Save game
        return (
            VERSION,
            CGameBase.__getstate__(self),
            self.PlayerControllerClass,
            self.StartPosition,
            self.NumPlayers,
            self.InfoClass,
            self.Info,
            self.IsSaveGame
            )
        
    def __setstate__(self, state):
        version = state[0]
        CGameBase.__setstate__(self, state[1])
        self.PlayerControllerClass = state[2]
        self.StartPosition = state[3]
        self.NumPlayers = state[4]
        if version >= 2:
            self.InfoClass = state[5]
            self.Info = state[6]
        if version >= 3:
            self.IsSaveGame = state[7]
    
    def Init(self):
        '''Initialize the game object.'''
        super(CGame, self).Init()
        actor = self.World.DefaultLevel.CreateActor(self.InfoClass)
        actor.Tag = '__GameInfo__'
        info = actor.GetComponent(self.InfoClass)
        assert info
        self.Info = info
        
    def Exit(self):
        '''Cleanup the game object.'''
        if self.Info:
            self.World.DefaultLevel.DestroyActor(self.Info.Actor)
            self.Info = None
        super(CGame, self).Exit()
        
    def Login(self, player):
        '''Create a new player controller.'''
        assert issubclass(self.PlayerControllerClass, APlayerController)
        level = self.World.DefaultLevel
        assert level
        pcact = None
        if self.IsSaveGame:
            for iact, ic in self.World.IterLevelComponents(self.PlayerControllerClass):
                pcact = iact
                GLogger.debug('found existing PC in save game: %s', pcact)
                break
        if pcact:
            pc = pcact.PlayerController
        else:
            GLogger.debug('creating new PC for player')
            pcact = level.CreateActor(self.PlayerControllerClass)
            pc = pcact.PlayerController
            self.NumPlayers += 1
        assert pc
        GLogger.info('player login: %s', pc)
        return pc

    def PostLogin(self, pc):
        pass
    
    def Logout(self, c):
        if isinstance(c, APlayerController):
            GLogger.info('player logout: %s', c)
            self.NumPlayers -= 1

    def OnTouchedLevelLink(self, link, actor):
        '''Called when an actor has touched a level link.'''
        if not link.DestLevelName:
            GLogger.warning('level link has no destination: %s', link)
            return

        # Only pawns can travel for now
        if not actor.Pawn:
            return

        if actor.Pawn.Controller:
            cancel = actor.Pawn.Controller.PawnTouchedLevelLink(link)
            if cancel:
                return

        errmsg = self.World.ChangeActorLevel(actor, link.DestLevelName, link.DestPosition)
        if errmsg:
            raise RuntimeError('actor level change failed: {}'.format(errmsg))
        GLogger.debug('actor changed level: %s to %s', actor, link.DestLevelName)

@NativeClass
class CWorld(CWorldBase, CObject):
    WORLD_PICKLE_HEADER = b'\x80\x02ckharlia.engine.world\n_UnpickleWorld\n'
    # These are the first bytes of a proper pickled level, used as first-step
    # verification of what we're unpickling
    LEVEL_PICKLE_HEADER = b'\x80\x02ckharlia.engine.level\n_UnpickleLevel\n'
    # Dict of globals that can be safely unpickled from levels
    SAFE_UNPICKLE_GLOBALS = {
        '__builtin__': [
            'getattr',
            '__import__'
            ],
        'stackless._wrap': True,
        'uuid': ['UUID'],
        'kharlia.__core': True,
        'kharlia._core': True,
        'kharlia.core': True,
        'kharlia.__engine': True,
        'kharlia._engine': True,
        'kharlia.engine': True,
        }
    UnpickleNewName = None
    
    def __init__(self, path=None):
        self.__initnative__()
        CObject.__init__(self)
        self.Path = path
        self.IsTransient = True if path is None else False
        # Map of traveling actors, actor -> originlevel
        self.TravelingActors = weakref.WeakKeyDictionary()
        self.AllowActorTraveling = True
        self.UnboundLevels = []
        assert not self._DefaultLevel
        self._CreateDefaultLevel()

    def __del__(self):
        self.Clear()

    def __getstate__(self):
        VERSION = 3
        # 2 - Add UnboundLevels
        # 3 - Actor traveling
        return (
            VERSION,
            CWorldBase.__getstate__(self),
            self.UnboundLevels if self.IsUnbound else None,
            list(self.TravelingActors.iteritems()),
            self.AllowActorTraveling
            )
    
    def __setstate__(self, state):
        version = state[0]
        CWorldBase.__setstate__(self, state[1])
        if version >= 2:
            self.UnboundLevels = state[2]
        if version >= 3:
            self.TravelingActors = weakref.WeakKeyDictionary(state[3])
            self.AllowActorTraveling = state[4]
        if self.IsUnbound:
            assert not self._DefaultLevel
            self._CreateDefaultLevel()

    def __reduce_ex__(self, protocol):
        assert protocol == 2
        return (_UnpickleWorld, (type(self),), self.__getstate__())

    def Clear(self):
        super(CWorld, self).Clear()

    def _DestroyLevels(self):
        levels = list(self.Levels.itervalues())
        for level in levels:
            self.DestroyLevel(level)
        
    def AddLevel(self, level, check=True):
        '''Add a level to the world. Name must be unique.'''
        GLogger.debug('adding level: %s', level.Name)
        super(CWorld, self).AddLevel(level, check)

    def RemoveLevel(self, level):
        '''Remove a level from the world. Calls DestroyTravelers().'''
        GLogger.debug('removing level: %s', level.Name)
        self.DestroyTravelers(level)
        super(CWorld, self).RemoveLevel(level)

    def DestroyTravelers(self, level):
        '''Destroy any actors in the world that originated from level.'''
        removes = []
        for actor, alevel in self.TravelingActors.iteritems():
            if alevel is level:
                removes.append(actor)
        for actor in removes:
            GLogger.debug('destroying traveling actor on remove level: %s', actor)
            actor.Level.DestroyActor(actor)

    def AddUnboundLevel(self, level, filepath):
        self.UnboundLevels.append((level.Name, level.GUID, filepath))

    def RemoveUnboundLevel(self, level):
        for i, e in enumerate(self.UnboundLevels):
            if e[1] == level.Name:
                del self.UnboundLevels[i]
                break

    def LoadUnboundLevel(self, name):
        assert self.Engine
        uindex = -1
        for i, e in enumerate(self.UnboundLevels):
            if e[0] == name:
                uname, uguid, upath = e
                uindex = i
                break
        if uindex == -1:
            raise ValueError('level not found in unbound level list: {0}'.format(name))
        with io.open(upath, 'rb') as f:
            level, resources = self.LoadLevelPackage(f, self.Engine.ResourceManager)
        if level.Name != uname:
            raise ValueError('loaded level name does not match unbound '
                             'level name: {0}, {1}'.format(level.Name, uname))
        assert level.GUID == uguid
        self.AddLevel(level)
        self.Engine.LoadResources(resources)
        del self.UnboundLevels[uindex]
        return level

    def GetPlayerClass(self, isLocal=False):
        '''Player class access for CWorldBase.'''
        if isLocal:
            return CLocalPlayer
        else:
            return CPlayer

    def IterLevels(self):
        return self.Levels.itervalues()

    def IterLevelActors(self, *args, **kwds):
        for level in self.IterLevels():
            for item in level.IterActors(*args, **kwds):
                yield item

    def IterLevelComponents(self, *args, **kwds):
        for level in self.IterLevels():
            for item in level.IterComponents(*args, **kwds):
                yield item

    def NetSendActorRPC(self, reliable, actor, funcname, *args):
        super(CWorld, self).NetSendActorRPC(reliable, actor, funcname, args)

    def _CreateDefaultLevel(self):
        dl = CLevel(None, '__Default__', (1, 1))
        self.AddLevel(dl)
        self._DefaultLevel = dl

    def CreatePlayerActor(self, player, remoterole=ENetRole.NONE):
        '''Setup a PC for a player that has just joined the world.'''
        if not player.IsLocal:
            GLogger.debug('creating controller for non-local player')
        if not self.Game:
            raise RuntimeError('no game set')
        try:
            pc = self.Game.Login(player)
        except Exception as ex:
            GLogger.error('Login failed: %s', ex)
            raise
        pc.Player = player
        player.Actor = pc.Actor
        #self.Controllers.add(pc)
        if remoterole != ENetRole.NONE:
            #pcact = pc.Actor
            #if not pcact.Network:
            #    pcact.AddComponent(ANetwork())
            pcact.RemoteRole = remoterole
            GLogger.debug('set remote role for player controller')
        self.Game.PostLogin(pc)
        #if pc.Pawn and remoterole != ENetRole.NONE:
        #    pawnact = pc.Pawn.Actor
        #    if not pawnact.Network:
        #        pawnact.AddComponent(ANetwork())
        #    pawnact.RemoteRole = remoterole
        #    GLogger.debug('set remote role for PC pawn')
        return pc

    def ChangeActorLevel(self, actor, destlevel, destpos):
        '''Move an actor from one level to another.

        The actor and destination levels must be in the world. If AllowActorTraveling
        is False, the move will fail unless destlevel is the same as the actor's
        current level. If the move succeeds, the actor will be added to the map of
        traveling actors.

        Returns: A string error message indicating the failure, or None on success.

        Arguments:
        actor -- The actor to be moved
        destlevel -- The destination level object, or a string with the level's name
        destpos -- The destination position
        '''
        assert actor and destlevel and destpos

        if actor.Level.World != self:
            return 'actor not in the world'

        # Resolve destination level
        if isinstance(destlevel, basestring):
            rdestlevel = self.GetLevel(unicode(destlevel))
            if not rdestlevel:
                return 'destlevel not in the world: {}'.format(destlevel)
        elif not isinstance(destlevel, CLevel):
            raise TypeError('destlevel must be string or CLevel')
        else:
            rdestlevel = destlevel

        # If the destination of the level is the same, just change the
        # actor's position.
        if rdestlevel == actor.Level:
            if actor.Transform:
                actor.Position = destpos
            return None
        
        if not self.AllowActorTraveling:
            return 'actor traveling not allowed'

        if rdestlevel.GetActorByName(actor.Name):
            return 'actor name conflict: {}'.format(actor.Name)

        # Ensure the new ID is available first
        newid = rdestlevel.CreateID() if rdestlevel.GetActorByID(actor._ID) else None

        # Notification. Things like the collider will want to know in order to
        # end any touches.
        actor.SendMessage('ChangingLevel', destlevel, destpos)

        # This map allows tracking and cleanup if a level is removed from the world
        if actor not in self.TravelingActors:
            self.TravelingActors[actor] = actor.Level

        # TODO: Perhaps move this into a CLevel method
        actor._Level._RemoveActor(actor)
        actor._Level = rdestlevel
        if newid:
            actor._ID = newid
        rdestlevel._AddActor(actor)
        if actor.Transform:
            actor.Position = destpos
        return None

    @staticmethod
    def SaveLevelPackage(level, file):
        '''
        Save a level into a package file.

        Arguments:
        level -- The level to be saved
        file -- A file-like object
        '''
        if not isinstance(level, CLevel):
            raise TypeError()
        GLogger.info('Saving package for level: %s', level.Name)
        data = cstringio.StringIO()
        CWorld.PickleLevel(level, data)
        data = data.getvalue()
        GLogger.info('Pickled data, now saving to file...')
        zipf = zipfile.ZipFile(file, 'w', zipfile.ZIP_DEFLATED)
        zipf.writestr('__level__.pickle', data)
        zipf.close()
        GLogger.info('Finished saving package')

    @staticmethod
    def LoadLevelPackage(file, resmgr, name=None):
        '''
        Load a level from a package file.
        '''
        GLogger.info('Loading level package...')
        zipf = zipfile.ZipFile(file, 'r')
        data = zipf.read('__level__.pickle')
        zipf.close()
        datafile = cstringio.StringIO(data)
        del data
        GLogger.info('Read level data from file, now unpickling...')
        lev = CWorld.UnpickleLevel(datafile, resmgr, name)
        GLogger.info('Unpickling finished')
        return lev

    @staticmethod
    def SaveWorldPackage(world, file):
        assert isinstance(world, CWorld)
        data = cstringio.StringIO()
        CWorld.PickleWorld(world, data)
        data = data.getvalue()
        zipf = zipfile.ZipFile(file, 'w', zipfile.ZIP_DEFLATED)
        zipf.writestr('__world__.pickle', data)
        zipf.close()

    @staticmethod
    def LoadWorldPackage(file, resmgr):
        GLogger.info('Loading world package...')
        with zipfile.ZipFile(file, 'r') as zipf:
            data = zipf.read('__world__.pickle')
        datafile = cstringio.StringIO(data)
        del data
        GLogger.info('Read world data from file, now unpickling...')
        world = CWorld.UnpickleWorld(datafile, resmgr)
        GLogger.info('Unpickling finished')
        return world

    @staticmethod
    def MakePersistentID(level=None):
        '''Create a persistent ID function for use with a pickler.
        
        Arguments:
        level -- The level if only a single level is being pickled. Limits
            actor/component references to this level.
        '''
        def persistent_id(obj):
            if DEBUG_SAVE_PICKLED_TYPES:
                objtype = type(obj)
                obs = str(obj)
                if len(obs) > 64:
                    obs = obs[:64]
                GPickledTypeList.append((unicode(objtype), obs))
                GPickledTypes.add(objtype)
        
            if isinstance(obj, stackless.tasklet):
                raise TypeError('can\'t pickle tasklets in levels')
            elif isinstance(obj, FResource):
                rtype = type(obj)
                rs = 'R:{0}.{1}:{2}'.format(rtype.__module__,
                                            rtype.__name__,
                                            obj.Name)
                #print('Pickling resource:', rs)
                return rs
            elif (level and
                    (isinstance(obj, CActor) or isinstance(obj, AComponent)) and
                    obj.Level != level):
                raise RuntimeError('attempted to pickle foreign actor')
            return None
        
        return persistent_id

    @staticmethod
    def MakePersistentLoader(reshandler):
        '''Create a persistent load function for use with an unpickler.

        Arguments:
        reshandler -- A callable that takes a resource class, name, and group, and
            returns a new or existing resource.
        '''
        def persistent_load(id):
            if id.startswith('R:'):
                tokens = id.split(':')
                if len(tokens) < 3:
                    raise RuntimeError('invalid resource ID')
                cls = FindGlobal(tokens[1])
                name = tokens[2]
                assert issubclass(cls, FResource)
                return reshandler(cls, name)
            
        return persistent_load

    @classmethod
    def PickleLevel(cls, level, file):
        '''
        Pickle a level.
        
        Arguments:
        level -- The level to be pickled.
        file -- A file-like object.
        '''
        global GPickledTypes, GPickledTypeList

        if level.GameStarted:
            raise RuntimeError('Can\'t pickle only a level when the game has started')

        pickler = cpickle.Pickler(file, cpickle.HIGHEST_PROTOCOL)
        pickler.persistent_id = cls.MakePersistentID(level)
        
        if DEBUG_SAVE_PICKLED_TYPES:
            GPickledTypes = set()
            GPickledTypeList = []
            
        try:
            pickler.dump(level)
        finally:
            if DEBUG_SAVE_PICKLED_TYPES:
                dfile = io.open('pickledtypes.txt', 'w')
                for typename, objstr in GPickledTypeList:
                    try:
                        dfile.write('{0} - {1}\n'.format(typename, objstr.encode('string_escape')))
                    except Exception:
                        dfile.write('{0} - (fail)\n'.format(typename))
                dfile.close()
                GPickledTypes = None
                GPickledTypeList = None

    @classmethod
    def MakeFindGlobal(cls, restrict=True):
        def find_global(module, fcls):
            GLogger.debug('finding global: %s %s', module, fcls)
            # No restricting yet
            if restrict:
                nlist = cls.SAFE_UNPICKLE_GLOBALS.get(module, None)
                if nlist is None:
                    raise RuntimeError('unpickling module %s not allowed', module)
                if nlist is not True:
                    if fcls not in nlist:
                        raise RuntimeError('unpickling attr %s of module %s not allowed', fcls, module)
            mod = __import__(module, fromlist=[fcls], level=0)
            return getattr(mod, fcls)
        return find_global

    @classmethod
    def UnpickleLevel(cls, file, resmgr, name=None):
        '''
        Unpickle a level from file.
        
        Arguments:
        file -- file-like object to unpickle from.
        resmgr -- The resource manager used to construct or retrieve
            unpickled resources.
        name -- A replacement for the level's name.
            
        Returns the new level object and a set of unpickled resources. The resources
        are not automatically loaded.
        '''
        #global GUnpicklingWorld, GUnpicklingLevel, GUnpicklingLevelName, GTraceInst

        if name is not None and not isinstance(name, basestring):
            raise TypeError('name must be instance of basestring')
         
        cls.UnpickleNewName = name
        
        try:
            pos = file.tell()
            head = file.read(len(cls.LEVEL_PICKLE_HEADER))
            file.seek(pos)
            
            if head != cls.LEVEL_PICKLE_HEADER:
                # TODO: Make this a validation error
                raise ValueError('pickle file header mismatch')
            
            resources = set()
            unpickler = cpickle.Unpickler(file)
            unpickler.find_global = cls.MakeFindGlobal(restrict=False)

            def reshandler(rcls, rname):
                r = resmgr.CreateResource(rcls, str(rname))
                resources.add(r)
                return r

            unpickler.persistent_load = cls.MakePersistentLoader(reshandler)
            level = unpickler.load()
            
            return level, resources
            
        finally:
            cls.UnpickleNewName = None
    
    @classmethod
    def PickleWorld(cls, world, file):
        global GPickledTypes, GPickledTypeList
        if world.GameStarted:
            GLogger.debug('Pickling world in gameplay')

        pickler = cpickle.Pickler(file, cpickle.HIGHEST_PROTOCOL)
        pickler.persistent_id = cls.MakePersistentID(level=None)

        if DEBUG_SAVE_PICKLED_TYPES:
            GPickledTypes = set()
            GPickledTypeList = []
        try:
            pickler.dump(world)
        finally:
            if DEBUG_SAVE_PICKLED_TYPES:
                dfile = io.open('pickledtypes_world.txt', 'w')
                for typename, objstr in GPickledTypeList:
                    try:
                        dfile.write('{0} - {1}\n'.format(typename, objstr.encode('string_escape')))
                    except Exception:
                        dfile.write('{0} - (fail)\n'.format(typename))
                dfile.close()
                GPickledTypes = None
                GPickledTypeList = None
        
    @classmethod
    def UnpickleWorld(cls, file, resmgr):            
        # Check header
        pos = file.tell()
        head = file.read(len(cls.WORLD_PICKLE_HEADER))
        file.seek(pos)
        
        if head != cls.WORLD_PICKLE_HEADER:
            raise ValueError('pickle file header mismatch')
        
        resources = set()
        unpickler = cpickle.Unpickler(file)
        unpickler.find_global = cls.MakeFindGlobal(restrict=False)

        def reshandler(rcls, rname):
            r = resmgr.CreateResource(rcls, str(rname))
            resources.add(r)
            return r

        unpickler.persistent_load = cls.MakePersistentLoader(reshandler)
        world = unpickler.load()

        return world, resources

def _UnpickleWorld(cls, *args, **kwds):
    assert cls is CWorld
    return cls(*args, **kwds)

copy_reg.constructor(_UnpickleWorld)